1 module hunt.application.staticfile; 2 3 import core.time; 4 import std.conv; 5 import std.string; 6 import std.datetime; 7 import std.path; 8 import std.digest.md; 9 import std.algorithm.searching : canFind; 10 static import std.stdio; 11 12 import hunt; 13 import hunt.application.controller; 14 import hunt.application.config; 15 import hunt.utils.string; 16 import hunt.http.code; 17 18 alias OnStaticFilePathSegmentation = string function(const string, const string) nothrow; 19 20 class StaticfileController : Controller 21 { 22 mixin MakeController; 23 24 __gshared OnStaticFilePathSegmentation onStaticFilePathSegmentation; 25 __gshared string staticFileMimetype = string.init; 26 27 @Action 28 void doStaticFile() 29 { 30 if (request.route.staticFilePath == string.init) 31 { 32 response.do404(); 33 34 return; 35 } 36 37 string staticFilename = mendPath(request.route.staticFilePath); 38 39 if ((staticFilename == string.init) || (!std.file.exists(staticFilename))) 40 { 41 response.do404(); 42 43 return; 44 } 45 46 FileInfo fi; 47 try 48 { 49 fi = makeFileInfo(staticFilename); 50 } 51 catch (Exception e) 52 { 53 response.doError(HTTPCodes.INTERNAL_SERVER_ERROR); 54 55 return; 56 } 57 58 if (fi.isDirectory) 59 { 60 response.do404(); 61 62 return; 63 } 64 65 auto lastModified = toRFC822DateTimeString(fi.timeModified.toUTC()); 66 auto etag = "\"" ~ hexDigest!MD5(staticFilename ~ ":" ~ lastModified ~ ":" ~ to!string(fi.size)).idup ~ "\""; 67 68 response.setHeader(HTTPHeaderCode.LAST_MODIFIED, lastModified); 69 response.setHeader(HTTPHeaderCode.ETAG, etag); 70 71 if (Config.app.application.staticFileCacheMinutes > 0) 72 { 73 auto expireTime = Clock.currTime(UTC()) + dur!"minutes"(Config.app.application.staticFileCacheMinutes); 74 response.setHeader(HTTPHeaderCode.EXPIRES, toRFC822DateTimeString(expireTime)); 75 response.setHeader(HTTPHeaderCode.CACHE_CONTROL, "max-age=" ~ to!string(Config.app.application.staticFileCacheMinutes * 60)); 76 } 77 78 if ((request.headerExists(HTTPHeaderCode.IF_MODIFIED_SINCE) && (request.header(HTTPHeaderCode.IF_MODIFIED_SINCE) == lastModified)) || 79 (request.headerExists(HTTPHeaderCode.IF_NONE_MATCH) && (request.header(HTTPHeaderCode.IF_NONE_MATCH) == etag))) 80 { 81 response.setHttpStatusCode(HTTPCodes.NOT_MODIFIED); 82 83 return; 84 } 85 86 auto mimetype = ((staticFileMimetype == string.init) ? getMimeContentTypeForFile(staticFilename) : staticFileMimetype); 87 response.setHeader(HTTPHeaderCode.CONTENT_TYPE, mimetype ~ ";charset=utf-8"); 88 89 response.setHeader(HTTPHeaderCode.ACCEPT_RANGES, "bytes"); 90 ulong rangeStart = 0; 91 ulong rangeEnd = 0; 92 93 if (request.headerExists(HTTPHeaderCode.RANGE)) 94 { 95 // https://tools.ietf.org/html/rfc7233 96 // Range can be in form "-\d", "\d-" or "\d-\d" 97 auto range = request.header(HTTPHeaderCode.RANGE).chompPrefix("bytes="); 98 if (range.canFind(',')) 99 { 100 response.doError(HTTPCodes.NOT_IMPLEMENTED); 101 102 return; 103 } 104 auto s = range.split("-"); 105 106 if (s.length != 2) 107 { 108 response.doError(HTTPCodes.BAD_REQUEST); 109 110 return; 111 } 112 113 try 114 { 115 if (s[0].length) 116 { 117 rangeStart = s[0].to!ulong; 118 rangeEnd = s[1].length ? s[1].to!ulong : fi.size; 119 } 120 else if (s[1].length) 121 { 122 rangeEnd = fi.size; 123 auto len = s[1].to!ulong; 124 125 if (len >= rangeEnd) 126 { 127 rangeStart = 0; 128 } 129 else 130 { 131 rangeStart = rangeEnd - len; 132 } 133 } 134 else 135 { 136 response.doError(HTTPCodes.BAD_REQUEST); 137 138 return; 139 } 140 } 141 catch (ConvException e) 142 { 143 response.doError(HTTPCodes.BAD_REQUEST, e.msg); 144 145 return; 146 } 147 148 if (rangeEnd > fi.size) 149 { 150 rangeEnd = fi.size; 151 } 152 153 if (rangeStart > rangeEnd) 154 { 155 rangeStart = rangeEnd; 156 } 157 158 if (rangeEnd) 159 { 160 rangeEnd--; // End is inclusive, so one less than length 161 } 162 // potential integer overflow with rangeEnd - rangeStart == size_t.max is intended. This only happens with empty files, the + 1 will then put it back to 0 163 164 response.setHeader(HTTPHeaderCode.CONTENT_LENGTH, to!string(rangeEnd - rangeStart + 1)); 165 response.setHeader(HTTPHeaderCode.CONTENT_RANGE, "bytes %s-%s/%s".format(rangeStart < rangeEnd ? rangeStart : rangeEnd, rangeEnd, fi.size)); 166 response.setHttpStatusCode(HTTPCodes.PARTIAL_CONTENT); 167 } 168 else 169 { 170 rangeEnd = fi.size - 1; 171 response.setHeader(HTTPHeaderCode.CONTENT_LENGTH, fi.size.to!string); 172 } 173 174 // write out the file contents 175 auto f = std.stdio.File(staticFilename, "r"); 176 scope(exit) f.close(); 177 178 f.seek(rangeStart); 179 auto buf = f.rawRead(new ubyte[rangeEnd.to!uint - rangeStart.to!uint + 1]); 180 response.setContext(buf); 181 } 182 183 private: 184 185 string mendPath(string path) 186 { 187 if (!path.startsWith(".") && !isAbsolute(path)) 188 { 189 path = "./" ~ path; 190 } 191 192 if (!path.endsWith("/")) 193 { 194 path ~= "/"; 195 } 196 197 string name = chompPrefix(request.path, request.route.getPattern()); 198 199 if (onStaticFilePathSegmentation !is null) 200 { 201 return onStaticFilePathSegmentation(path, name); 202 } 203 204 return path ~ name; 205 } 206 207 struct FileInfo { 208 string name; 209 ulong size; 210 SysTime timeModified; 211 SysTime timeCreated; 212 bool isSymlink; 213 bool isDirectory; 214 } 215 216 FileInfo makeFileInfo(string fileName) 217 { 218 FileInfo fi; 219 fi.name = baseName(fileName); 220 auto ent = DirEntry(fileName); 221 fi.size = ent.size; 222 fi.timeModified = ent.timeLastModified; 223 version(Windows) fi.timeCreated = ent.timeCreated; 224 else fi.timeCreated = ent.timeLastModified; 225 fi.isSymlink = ent.isSymlink; 226 fi.isDirectory = ent.isDir; 227 228 return fi; 229 } 230 231 bool isCompressedFormat(string mimetype) 232 { 233 switch (mimetype) 234 { 235 case "application/gzip", "application/x-compress", "application/png", "application/zip", 236 "audio/x-mpeg", "image/png", "image/jpeg", 237 "video/mpeg", "video/quicktime", "video/x-msvideo", 238 "application/font-woff", "application/x-font-woff", "font/woff": 239 return true; 240 default: return false; 241 } 242 } 243 }